iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 26

Day 26:數值語意、多幣別與計量單位

  • 分享至 

  • xImage
  •  

Day 26:數值語意、多幣別與計量單位

前面幾章問的是這個值進不進得去、傳不傳得到、要不要被記下來。這一篇換一個問題:進去了的那個值代表什麼。

一張單據上的數量、單價、金額、折扣率、匯率,在 TableSchema 那份定義裡長得幾乎一樣,都是一個十進位數字加一組精度。真正的差別在三個地方:要顯示幾位、寫進去的時候要不要捨、位數由誰說了算。多數系統把這三件事分別交給資料表的 DDL、畫面上的格式字串、業務邏輯裡的 Math.Round,三處各改各的。要好維護,這三件事得由同一套機制來管。

本篇說明:

  1. 數值欄位上標的是語意而不是位數
  2. 金額的位數依幣別而定
  3. 數量與重量的位數依計量單位而定
  4. 明細先捨再加還是加完再捨,這個順序決定合計對不對

一、標的是語意,不是位數

資料表宣告 decimal(19,4) 之後,你知道這一欄最多放得下四位小數。但你不知道它該顯示幾位、算出來的值要不要捨,也不知道客戶要求單價只顯示兩位時,該改這一欄還是別的地方。

框架的作法是在 FormSchema 的欄位(FormField)上宣告一個語意分類 NumberKind,要留幾位、要不要捨,都由它決定:

NumberKind 語意 捨入策略 位數來源
Quantity 數量 Round 計量單位
Weight 重量 Round 計量單位
Amount 金額 Round 幣別
Percent 百分比 Round 公司
UnitPrice 單價 Preserve 公司
Cost 成本 Preserve 公司
ExchangeRate 匯率 Preserve 系統固定

算出來的數字要捨,拿去算的不捨。金額要印在單據上、加進合計、記進帳,得捨到該有的位數;單價、成本、匯率是拿去乘的,捨了就會把誤差帶進後面每一次計算。所以框架的捨入方法 RoundByKind 遇到這三種會原樣送回。單價顯示四位只是給人看,框架不會因此把它捨成四位。

沒標 NumberKind 的欄位,框架不給格式也不捨入,漏標了也不會報錯。

位數能不能先算好,看它在使用者操作單據的過程中會不會變。百分比、單價、成本的位數由公司訂,匯率由框架固定五位,這四種都不會變,所以送定義的時候就先把格式算好,前端照著印。

金額、數量與重量的位數會變:金額跟著幣別走,數量與重量跟著單位走,分別是後面兩節。


二、金額的位數由幣別決定

幣別的小數位是幣別自己的性質,不是公司政策:日圓沒有分、美元有兩位、巴林第納爾有三位,這是 ISO 4217 訂的,不會因為換一家公司就不一樣。所以幣別位數放在一張全系統共用的主檔裡,個別公司動不了它。公司能決定的是本幣用哪一種,以及最終應付金額要不要再對齊一個現金捨入單位(後面會談)。

那張主檔就是一份定義檔,跟表單定義放在同一個地方、走同一套快取(Day 4 那十三種定義都是這樣),也跟著一起送給前端(節選自框架附的 CurrencySettings.xml):

<CurrencyItem Code="USD" Numeric="840" Rounding="0.01" Symbol="$" Name="US Dollar" />
<CurrencyItem Code="JPY" Numeric="392" Rounding="1" Symbol="¥" Name="Japanese Yen" />
<CurrencyItem Code="BHD" Numeric="048" Rounding="0.001" Symbol="BD" Name="Bahraini Dinar" />

它存的不是位數,而是「自然最小單位」,位數再從它換算(0.01 是兩位、1 是零位)。最小單位才是貨幣真正存在的東西,後面的現金捨入用到的也是它。

金額的位數不能先算好,因為使用者可能在畫面上把幣別從美元改成日圓。所以定義裡不寫金額的位數,只寫「我的幣別在哪一欄」;沒指定就用單據上的幣別欄,等那一欄有值再算。

於是同一欄的每一列可以有不同的位數。美元加日圓沒有意義,頁尾只在整欄都是同一種幣別時才顯示合計;換算成公司本幣的那一欄只有一種幣別,一定加得出來。

幣別欄沒填,就用公司的本幣。本幣是公司必填的設定,沒設的話,框架算金額時會直接擲出例外,而不是退回預設位數。


三、數量與重量的位數由單位決定

數量也是同樣的道理:填「件」不該有小數,填「公斤」就可以是 0.375。能不能有小數,看的是這一列填了哪個單位,不是看欄位。

單位主檔也是全系統共用的定義檔,每個單位的位數是那個單位自己的性質,不歸公司管(節選自框架附的 UnitSettings.xml):

<UnitItem Code="PCS" Decimals="0" Dimension="count" Name="Pieces" />
<UnitItem Code="KG" Decimals="3" Dimension="weight" Name="Kilogram" />

兩張主檔的差別在存法:計量單位直接存位數;幣別存的是最小單位,再換算成位數,因為現金捨入要用到最小單位。

作法和金額一致:數量欄只寫「我的單位在哪一欄」,位數依那一欄的值查單位主檔。差別在沒填的時候:

代碼從哪一欄來 那一欄沒填 公司層的預設
金額 金額欄指定的幣別欄,沒指定就用單據上的幣別欄 用公司本幣 本幣(必填)
數量/重量 數量欄指定的單位欄 沒有可以頂上的 沒有預設單位

公司有本幣,卻不會有預設單位,因為同一張單的明細本來就可以一列公斤、一列箱。

原則是:標成 QuantityWeight 的欄位就必須指定單位欄;不需要單位的數字,用一般數值,不標 NumberKind


四、先捨再加,還是加完再捨

三筆明細,每筆都是一件乘以單價 10.3333。直覺上全精度加完再捨一次比較精確。用案例 FormSchema 的算式實際跑一次:

作法 每列顯示 合計
先捨再加 10.33 30.99
加完再捨 10.33 31.00

差了一分錢,錯的是看起來比較精確的那一個。單上印著三個 10.33,使用者自己加是 30.99,表頭卻寫 31.00,這張單自己就對不上。ERP 要求表頭合計必須等於明細加總,一分都不能差,因為單據要被列印、對帳、稽核。

框架的作法是每一列算完就先捨,合計拿到的都是捨過的值,加起來自然相等,這條規則叫 round-then-sum。框架只在這裡自動捨入,使用者手填的數字不會被捨。

捨入分成兩層

明細每一列捨到幣別的自然小數,這一層全系統一致。單據最後的應付金額可以再加一層:對齊公司設定的現金捨入單位。例如瑞士法郎沒有一分的硬幣,公司可以設成 0.05,12.34 就收 12.35、12.32 收 12.30。

兩層的性質不同。第一層是去掉不存在的精度;第二層會刻意產生差額,多收的一分或少收的兩分都要記進捨入科目。所以框架這一層只回傳應付金額,差額由呼叫端自己算、自己記。

資料庫那一格是容量,不是位數

資料表欄位也有精度,但那是容量上限,只表示最多放得下幾位,跟這個值該顯示或捨到幾位無關。同一個金額型別,在五家資料庫分別建成 decimal(19,4)numeric(19,4)DECIMAL(19,4)NUMBER(19,4)NUMERIC(19,4),寫法不同,數字相同。

上限刻意訂得比業務位數高很多,否則每加一家公司、每多一種貨幣就要 ALTER TABLE 一次,沒辦法維運。

超出容量的位數,框架目前不處理。值送進資料庫時只帶值、型別、長度與允不允許 NULL,不帶精度,寫入前也不會把值捨到欄位的容量。

所以超出的部分由各家資料庫自己決定。往四位小數的欄位寫入 1.234567,SQLite 原樣保存(實測),其他資料庫會依自己的規則收掉,同一筆資料存進不同資料庫,精度就不一樣。

資料庫可以用 CHECK 約束限制位數,但那只會擋下不合的資料,不會捨入。要以正確的位數存進去,得由框架在寫入前處理,這一步還沒有做。


回到 Northwind

案例的 FormSchema 只有兩個欄位標了語意,而且正好一邊一個(節選自 Order.FormSchema.xml):

<FormField FieldName="unit_price" DbType="Currency" NumberKind="UnitPrice" />
<FormField FieldName="amount" DbType="Currency" NumberKind="Amount" ReadOnly="true"
           ValueExpression="quantity * unit_price * (1 - discount)" />

第一個是單價,位數由公司訂。送定義時 unit_price 會拿到四位的格式,但案例手寫的版面那一欄沒有帶標記,畫面上顯示的仍是原值;填 10.33335 進去,跑完一輪求值還是 10.33335,沒有被捨。

第二個是 Day 8 那個計算欄,屬於幣別那一類。案例沒有幣別欄,所以用公司本幣 USD,從幣別主檔查出兩位,框架求完值就捨到兩位。送定義時 amount 的格式留空,也沒有指向幣別欄;畫面上的兩位,是版面那一欄的標記依本幣算出來的。多幣別和計量單位都示範不出來,ExchangeRate 也沒用到。

表頭總額不在定義裡,是 OrderBO 存檔前把明細的 amount 加起來。那些 amount 在加總前已經由框架逐列捨過,所以符合 round-then-sum:框架負責捨,應用負責加。Day 8 說過跨列加總是算式目前寫不出來的,捨入這一半框架已經做了,剩下的只有加總。


小結

這一篇從欄位上的語意標記講到資料表的容量,都在回答同一件事:一個數字的位數由誰決定。

答案分散在不同地方,而且是刻意的:單價、成本、百分比與匯率由公司或框架訂,因為那是政策;金額由幣別決定,數量與重量由單位決定,因為那是幣別和單位本身的性質;應付金額要不要再對齊,由公司決定,因為那是收款政策;欄位放得下幾位由資料表決定,因為那只是容量。

把這幾件事混在一起就會出錯:位數綁在資料表上,換一種貨幣就要改結構;位數綁在顯示上,單價與匯率會被捨掉精度;加總之後才捨入,表頭就對不上明細。

位數之外還有值本身。框架只會自動捨它自己算出來的值,使用者填的單價、折扣、手輸的金額一律不動。捨入是針對計算結果的規則,不是去修正使用者的輸入;框架一旦替使用者改數字,帳上就會多出一筆沒人知道是誰改的差額。

明天換一種資料。數字至少看起來都一樣,時間卻不是:同一個 CLR 型別裡裝著語意不同的幾種值,型別系統分不出來。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 25:稽核軌跡與異動的前後值
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言